Skip to content

js_parser: fold +, - and ~ of a constant-folded string; stop folding non-ascii strings to NaN - #39072

Open
robobun wants to merge 1 commit into
mainfrom
farm/ab793296/to-number-rope-strings
Open

robobun wants to merge 1 commit into
mainfrom
farm/ab793296/to-number-rope-strings

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With minify: { syntax: true }, x = +("1" + "2") is printed as x = +"12"; transpiling that output again gives x = 12. Same for -("1" + "2") (-"12", then -12) and ~("1" + "2") (~"12", then -13). A transform(transform(x)) === transform(x) check flags it.
  • The same thing happens without minify inside enums, where initializers are always folded: enum E { A = "1" + "2", B = +("3" + "4"), C = -A } emits E[E["B"] = +"34"] = "B" and E[E["C"] = -"12"] = "C", so B and C are not numeric constants and their uses are not inlined. esbuild emits 34 and -12.
  • Cause: "1" + "2" is folded into a rope (see Background), and ExprData::to_number (src/ast/expr.rs) returned None as soon as the string had a next segment, in both its EString arm and its EInlinedEnum arm. The +/- folds and the ~ fold in e_unary (src/js_parser/visit/visit_expr.rs) therefore skipped ropes; the printer flattens the rope, so the second pass saw a plain literal and folded it.
  • Second symptom in the same function: string_to_equivalent_number_value folded every UTF-8 string containing non-ascii bytes to NaN. Non-ascii source literals are stored as UTF-16 and were never folded, but the strings bun injects as UTF-8 (--define values, inlined process.env) are: with --define 'X="\u00a01"' (U+00A0 is whitespace to StringToNumber, so the value is 1), +X became NaN and, under minify, ~X became -1 (the JS values are 1 and -2). Flattening ropes on its own would have extended this to +(X + "1"), which is left unfolded today.

Fix

  • to_number takes the arena and flattens a rope before converting it; str.resolve_rope_if_needed(bump) is the fixing line. The EInlinedEnum arm recurses into the inlined value instead of carrying a copy of the string arm; an inlined enum value is only ever an ENumber or a copy of the member's EString (the EnumNumber / EnumString arms in p.rs and fold.rs), so the arm folds what it folded before, plus ropes.
  • string_to_equivalent_number_value returns None for a non-ascii string instead of NaN; return None is the fixing line. JSC__jsToNumber reads latin1, so only ascii bytes can be handed to it as-is; declining leaves the expression unfolded, which is what UTF-16 literals already get.
  • Why this is right: flattening does not change the string's value, so a rope folds to exactly what the same characters in one literal already folded to. Every ascii operand that folded before folds to the same number; the only operands that stop folding are non-ascii UTF-8 strings, whose folded value was a guess. Flattening rewrites the root node's data/next only and does not touch the linked segments, which is the in-place flattening Data::eql and extract_string_values in the same file already perform on these nodes, inlined enum copies included.
  • Matches esbuild, whose string folding produces one flat string, so +("1" + "2") and the enum members above fold there.
  • Callers: SideEffects::to_number (scan_side_effects.rs) takes the arena too and its three uses in e_unary pass p.arena; there are no other callers. (js_parser: fold the unary produced by distributing it over a comma operand #39049 reshuffles the same three lines in e_unary; whichever lands second has a one-line conflict per call.)
  • Tests: test/bundler/transpiler/transpiler.test.js ("simplification": the rope folds and their fixed point, the enum cases, the define cases) and test/bundler/bundler_minify.test.ts (minify/ConstantFoldingUnaryOnFoldedString, minify/ConstantFoldingUnaryPlusNonAsciiDefine, through bun build). All five fail on bun 1.4.0 (+"12", E[E["B"] = +"34"], x = NaN, E.B + E.C + E.D left as is) and pass with this change.
  • Also ran in full with the debug build: transpiler.test.js, bundler_minify.test.ts, bundler_string.test.ts, bundler_edgecase.test.ts, bundler_regressions.test.ts, transpiler_constant_fold_eqeq.test.ts, esbuild/ts.test.ts, esbuild/dce.test.ts, esbuild/default.test.ts, transpiler/*; and cargo clippy -p bun_ast -p bun_js_parser.

Background

  • Rope: when constant folding concatenates two string literals, bun does not build the joined string. It links the right operand onto the left operand's E::String node (next / end / rope_len in src/ast/e.rs, src/ast/fold_string_addition.rs) and the printer walks the chain. resolve_rope_if_needed replaces the root node's bytes with the concatenation (allocated in the parser's arena, hence the new parameter) and clears next. Anything that reads a folded string's bytes has to flatten first or walk the chain; data alone is the first piece.
  • Inlined enum: in a TypeScript file, a reference to an enum member with a known value is replaced by an E::InlinedEnum wrapping a copy of the member's number or string (printed as 12 /* A */). Enum initializers are folded whatever the minify settings, so a member defined by concatenation is a rope and each use of it is a rope.
  • should_fold_typescript_constant_expressions: the parser flag under which string addition is folded (so ropes exist at all) and under which ~ folds. It is on under minify syntax and always on inside enum bodies. The +x / -x folds run unconditionally, which is why the enum case reproduces without minify.

…op folding non-ascii strings to NaN

ExprData::to_number returned None for any string with a rope segment, so
+("1" + "2") was printed as +"12" under minify syntax (and in enum
bodies), and a second transpile of the output folded it to 12. It now
flattens the rope through the arena first, and the inlined-enum arm
recurses into the inlined value instead of duplicating the string arm.

string_to_equivalent_number_value folded every non-ascii utf-8 string to
NaN; U+00A0 and other non-ascii whitespace are stripped by StringToNumber,
so a --define value like "\u00a01" folded to the wrong constant. It now
declines, leaving the expression unfolded like utf-16 literals already are.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, self-review in progress.

Reproduced on bun 1.4.0 with new Bun.Transpiler({ minify: { syntax: true } }): x = +("1" + "2"); transforms to x = +"12";, and transforming that output again gives x = 12; (same for - and ~, and for enum members such as B = +("3" + "4") without minify). Also reproduced +X folding to NaN with --define 'X="\u00a01"' (value is 1).

Fix is in this PR (#39072): ExprData::to_number flattens rope strings before converting, and declines non-ascii strings instead of folding them to NaN. Tests: test/bundler/transpiler/transpiler.test.js (simplification block) and test/bundler/bundler_minify.test.ts; all of them fail on the unfixed build and pass with the fix.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Aug 15th, 2026

❌ @robobun, your commit 20895ec has some failures in Build #98151 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39072

That installs a local version of the PR into your bun-39072 executable, so you can run:

bun-39072 --bun

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5390c7f2-6438-4ea3-8d07-18307c12ed0d

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 20895ec.

📒 Files selected for processing (5)
  • src/ast/expr.rs
  • src/js_parser/scan/scan_side_effects.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/bundler_minify.test.ts
  • test/bundler/transpiler/transpiler.test.js

Walkthrough

Changes

Unary numeric constant folding now resolves rope strings and inlined enum values through arena-aware conversion. Unsupported and non-ASCII strings remain unfurled. Bundler and transpiler tests cover unary operators, whitespace, enums, defined values, and dynamic operands.

Unary constant folding

Layer / File(s) Summary
Numeric conversion contract
src/ast/expr.rs
Data::to_number now resolves rope strings with Bump, recursively handles inlined enum values, preserves zero for empty strings, and returns None for unsupported or non-ASCII strings.
Parser folding integration
src/js_parser/scan/scan_side_effects.rs, src/js_parser/visit/visit_expr.rs
Numeric conversion receives the parser arena for unary complement, unary-plus, and unary-minus folding.
Unary folding regression coverage
test/bundler/bundler_minify.test.ts, test/bundler/transpiler/transpiler.test.js
Tests cover concatenated strings, ropes, enums, defined values, ASCII whitespace, non-ASCII strings, and dynamic operands.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 20895

This localized change fixes incorrect constant folding for string ropes and non-ASCII defined values, with regression coverage and no actionable merge-blocking risk remaining beyond normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes to unary folding and non-ASCII string handling.
Description check ✅ Passed The description clearly explains the problem, fix, rationale, affected callers, and verification steps, despite using different section headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted constant-folding fix with a clear mechanism and thorough tests.

Checked that the let mut str = *str; str.resolve_rope_if_needed(bump) pattern matches the existing StoreRef copy-then-DerefMut idiom used at six other sites in expr.rs, and that EString::push debug-asserts both segments are UTF-8, so resolve_rope_if_needed's root-only is_utf8() gate is sound for ropes.
Confirmed the only ExprData::to_number callers are the three SideEffects::to_number sites in e_unary (all updated) plus the new EInlinedEnum recursion; the other to_number(global) hits are JSValue::to_number.
Verified string_to_equivalent_number_value returning None on non-ascii only narrows folding (leaves the expression as-is), never miscomputes.

Extended reasoning...

Overview

The PR fixes two constant-folding issues in ExprData::to_number (src/ast/expr.rs): (1) it now flattens rope strings via resolve_rope_if_needed(bump) before converting, so +("1" + "2") folds to 12 in one pass instead of +"12" then 12; (2) string_to_equivalent_number_value now returns Option<f64> and declines non-ascii UTF-8 strings instead of folding them to NaN (which was wrong for e.g. U+00A0-prefixed numbers from --define). The EInlinedEnum arm is deduplicated into a recursive call. SideEffects::to_number and its three call sites in visit_expr.rs are updated to thread p.arena through. Tests are added to transpiler.test.js (rope folds, fixed-point, enum, define) and bundler_minify.test.ts (two itBundled cases through bun build).

Security risks

None. This is compile-time constant folding of string/number literals in the transpiler. The change only narrows incorrect folding (non-ascii → decline) and extends correct folding (ropes → flatten first). Declining to fold is always safe — the expression is left as-is and evaluated at runtime. No untrusted input reaches a new code path; JSC__jsToNumber was already called on the same bytes.

Level of scrutiny

Medium. Constant folding bugs can silently corrupt bundler output, so correctness matters. But the change is small (~30 lines of logic), the mechanism is fully explained in the PR description, and the fix follows established patterns: the StoreRef copy + DerefMut in-place mutation is used identically at expr.rs:872-876 and expr.rs:2799-2820. I verified EString::push debug-asserts all rope segments are UTF-8, so resolve_rope_if_needed's root-only encoding check is sound and slice8() after flattening reads the whole string. I grepped for all to_number and SideEffects::to_number callers — the three e_unary sites are the only ones on ExprData (the rest are JSValue::to_number(global)).

Other factors

Test coverage is strong: fixed-point assertions (transform(transform(x)) === transform(x)), enum-without-minify cases, --define UTF-8 cases with both ascii (folds) and non-ascii (declines), negative cases (+("1" + y) stays unfolded), and end-to-end via itBundled. The author reports all five new tests fail on 1.4.0 and pass with the fix, and ran the full transpiler/bundler/esbuild suites plus clippy. The EInlinedEnum recursion technically now also matches ENull/EUndefined/EBoolean, but per the EnumNumber/EnumString construction those never occur, and even if they did the fold would be correct. No prior reviews to address.

@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

#46915 has the non-ASCII half of this PR: Data::to_number (src/ast/expr.rs) trims the white space of JavaScript and no longer reads a non-ASCII byte as a character. The fold of a constant-folded string (+("1" + "2")) is only in this PR. The two PRs change the same arms of Data::to_number, so the second to merge needs a rebase.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

#47048 adds EString::to_number (src/ast/e.rs): Number(text) for an 8-bit string and for a UTF-16 string, and None for a rope. It is there for the React Compiler, and it also fits the two string arms of Data::to_number that this PR changes.

With it, each arm can flatten the rope as this PR does, and then return str.to_number(). Two results differ from this PR:

  • +X with --define 'X="\u00A01"' folds to 1. This PR leaves it as +"\u00a01", and minify/ConstantFoldingUnaryPlusNonAsciiDefine pins that.
  • +"é" folds to NaN. Main does not fold it.

The branch robobun/f4408ea8/parser-unary-to-number has that change as one commit on top of #47048 (749c9a0, 6 files, +58/-68). It is not a PR, because this PR owns those arms. The commit:

  • makes both arms str.to_number(), without the rope change of this PR,
  • deletes string_to_equivalent_number_value and the export JSC__jsToNumber,
  • makes npm_config_bool call bun_core::fmt::js_string_to_number,
  • sets the transpiler cache version to 44.

What was checked. On the current base the commit compiles (cargo check). Its tests and the numbers below are from an earlier base (3396aea), with release builds:

A review of that commit asked for rows that it does not have yet:

  • a macro result (+five() is +"5" on main),
  • a rope: +("1" + "2") must not fold to 1,
  • a UTF-16 string of more than 64 units,
  • +"\u0131", which must be NaN.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant